// Copyright (c) 2007, Brian Duff
// See LICENSE for license details.

package org.dubh.orablogs.ag;

import com.sun.syndication.feed.synd.SyndContent;
import com.sun.syndication.feed.synd.SyndEntry;
import com.sun.syndication.feed.synd.SyndFeed;

import com.sun.syndication.fetcher.FetcherException;
import com.sun.syndication.fetcher.impl.HashMapFeedInfoCache;
import com.sun.syndication.fetcher.impl.HttpURLFeedFetcher;

import com.sun.syndication.io.FeedException;

import java.io.IOException;

import java.net.MalformedURLException;
import java.net.URL;

import java.sql.Timestamp;

import java.util.Date;
import java.util.List;
import java.util.TimeZone;
import java.util.concurrent.Callable;
import java.util.logging.Level;
import java.util.logging.Logger;

import javax.persistence.NoResultException;

import org.jdom.Element;

/**
 * Task to fetch an RSS or Atom feed and update the orablogs database.
 */
final class FetchFeed implements Callable<Void>
{
  private static final Logger LOG = Logger.getLogger( FetchFeed.class.getName() );
  private final Blog _blog;
  private final OrablogsService _service;
  private final HtmlSanitizer _sanitizer;
  
  private volatile boolean _complete = false;
  
  public FetchFeed( Blog blog )
  {
    _blog = blog;
    _service = new OrablogsService();
    _sanitizer = new HtmlSanitizer( blog.getFeedUrl() );
  }
  
  public Void call()
  {
    try
    {
      if ( !isTimeToUpdate() ) return null;
      
      URL feedUrl = createFeedUrl();
      if ( feedUrl == null ) return null;
      
      LOG.info( "Checking " + feedUrl + "..." );
      
      SyndFeed feed = readFeed( feedUrl );
      if ( feed == null ) return null;
      
      processFeed( feed );
      
      return null;
    }
    catch ( RuntimeException e )
    {
      LOG.log( Level.SEVERE, "Runtime exception processing " + _blog.getFeedUrl(), e );
      return null;
    }
    finally
    {
      _complete = true;
    }
  }
  
  boolean isComplete()
  {
    return _complete;
  }
  
  private void processFeed( SyndFeed feed )
  {
    LOG.info( feed.getEntries().size() + " entries from " + _blog.getFeedUrl() );
    int newEntryCount = 0;
    for ( Object o : feed.getEntries() )
    {
      if ( updateEntry( (SyndEntry)o) ) newEntryCount++;
    }
    updateFeed( feed, newEntryCount );
  }
  
  /**
   * Update an entry in the database.
   * 
   * @param feedEntry the entry to update.
   * @return <tt>true</tt> if the entry was new, <tt>false</tt> otherwise.
   */
  private boolean updateEntry( SyndEntry feedEntry )
  {
    try
    {
      BlogEntry dbEntry = _service.findBlogEntryByGuid( _blog, 
        entryGuid( feedEntry ) );
      updateExistingEntry( feedEntry, dbEntry );
      return false;
    }
    catch ( NoResultException noResult )
    {
      createNewEntry( feedEntry );
      return true;
    }
  }
  
  private void updateExistingEntry( SyndEntry feedEntry, BlogEntry dbEntry )
  {
    // We disallow updates on certain feeds, because their feeds produce
    // broken dates.
    if ( !_blog.isUpdateAllowed() )
    {
      info( "Updating existing entries is supressed" );
      return;
    }
    
    synchronize(feedEntry, dbEntry);
    
    _service.mergeEntity( dbEntry );
  }
  
  private String entryLink( SyndEntry entry )
  {
    String link = entry.getLink();
    if ( link != null ) return link;
    link = entry.getUri();
    if ( link != null ) return link;
    
    return _blog.getFeedUrl();
  }

  private void synchronize(SyndEntry feedEntry, BlogEntry dbEntry)
  {
    dbEntry.setTitle( feedEntry.getTitle() );
    dbEntry.setLink( entryLink( feedEntry ) );
    dbEntry.setEntryDate( toUTC( feedEntry.getPublishedDate() ) );
    dbEntry.setBody( summaryForEntry( feedEntry ) );
  }
  
  private String entryGuid( SyndEntry entry )
  {
    if ( entry.getUri() != null ) return entry.getUri();
    if ( entry.getLink() != null ) return entry.getLink();
    return entry.getTitle();  // Best we can do?
  }
  
  private void createNewEntry( SyndEntry feedEntry )
  {
    BlogEntry newEntry = new BlogEntry();
    synchronize( feedEntry, newEntry );
    
    newEntry.setGuid( entryGuid( feedEntry ) );
    newEntry.setGatheredDate( nowUTC() );
    
    _blog.entries().add( newEntry );
    newEntry.setBlogger( _blog );
    _service.persistEntity( newEntry );
    _service.mergeEntity( _blog );
  }
  
  private void updateFeed( SyndFeed feed, int newEntryCount )
  {
    _blog.setName( feed.getTitle() );
    _blog.setLastPolled( nowUTC() );
    _blog.setLastUpdate( toUTC( feed.getPublishedDate() ) );
    _blog.setEntryCount( _blog.getEntryCount() + newEntryCount );
    _blog.setUrl( feed.getLink() );
    
    _service.mergeEntity( _blog );
  }
  
  private SyndFeed readFeed( URL feedURL )
  {
    try
    {
      return new HttpURLFeedFetcher( HashMapFeedInfoCache.getInstance() )
        .retrieveFeed( feedURL );
    }
    catch (IOException e)
    {
      fail( "Unable to retrieve from " + feedURL, e );
    }
    catch (FeedException e)
    {
      fail( "Unable to retrieve from " + feedURL, e );
    }
    catch (FetcherException e)
    {
      fail( "Unable to retrieve from " + feedURL, e );
    }
    return null;
  }
  
  private URL createFeedUrl()
  {
    String urlAsString = _blog.getFeedUrl();
    try
    {
      return new URL( urlAsString );
    }
    catch ( MalformedURLException e )
    {
      fail( "Invalid feed URL: " + urlAsString, e );
      return null;
    }
  }
  
  private void fail( String message, Throwable cause )
  {
    LOG.log( Level.SEVERE, nameOfBlog() + ": " + message, cause );
  }
  
  private void info( String message )
  {
    LOG.info( nameOfBlog() + ": " + message );
  }
  
  /**
   * Determine whether it is time to udpate this feed. It's time to update the 
   * feed if its poll interval has elapsed since the last try.
   * 
   * @return <tt>true</tt> if it is time to update this feed.
   */
  private boolean isTimeToUpdate()
  {
    int pollfreq = _blog.getPollFrequency();
    Timestamp lastPolled = _blog.getLastPolled();
    if ( lastPolled == null ) return true;
    
    Date now = new Date();
    
    long nowMs = nowUTC().getTime();
    long thenMs = lastPolled.getTime();
    
    long elapsed = nowMs - thenMs;
    return result( elapsed > pollfreq, 
      "Skipping " + nameOfBlog() + ": only " + elapsed + "ms have elapsed since last check. Poll frequency is " + pollfreq );
  }
  
  private boolean result( boolean value, String logMessage )
  {
    if ( !value ) LOG.info( logMessage );
    return value;
  }
  
  private String nameOfBlog()
  {
    return _blog.getForenames() + " " + _blog.getSurname();
  }
  
  private String summaryForEntry( SyndEntry entry )
  {
    return _sanitizer.sanitize( getEntrySummary( entry ));
  }
  
  private String getEntrySummary( SyndEntry entry )
  {
    if ( entry.getDescription() != null )
    {
      return entry.getDescription().getValue();
    }
    
    if ( entry.getContents() != null && entry.getContents().size() > 0 )
    {
      SyndContent content = (SyndContent) entry.getContents().get( 0 );
      return content.getValue();
    }
    
    // Deal with borked content produced by blogger. Tsk, tsk Google.
    //entry.getForeignMarkup()
    
    List foreign = (List)entry.getForeignMarkup();
    if ( foreign != null )
    {
      for ( Object o : foreign )
      {
        if ( o instanceof Element )
        {
          Element e = (Element)o;
          if ( "summary".equals( e.getName() ) )
          {
            return e.getTextTrim();
          }
        }
      }
    }
    
    return "<p>No summary available.</p>";
  }
  
  
  /**
   * Get the current time, in UTC.
   * @return
   */
  private Timestamp nowUTC()
  {
    Date nowLocal = new Date();
    return toUTC( nowLocal );
  }  
  
  private Timestamp toUTC( Date localDate )
  {
    if ( localDate == null ) return null;
    TimeZone localZone = TimeZone.getDefault();
    int offset = localZone.getOffset( localDate.getTime() );
    
    return new Timestamp( localDate.getTime() - offset );
    
  }
}
